"""This module contains a bunch of useful stuff to support the pymak
environment.
"""

# Python imports
import sys
import os
import re
import traceback
import logging
import cStringIO
import shutil
import stat
import filecmp
import subprocess
import threading

# Package imports
import path

__all__ = [
    'PyMakException',
    'NoMatchException',
    'CommandFailedException',
    'expand_variables',
    'ImportsDict',
    'InstallsDict',
    'BACKWARDS_COMPATIBILITY_MODE',
    'SINGLETON_SUBCOMPONENT',
    'ONE_OR_MORE_SUBCOMPONENTS',
    'ZERO_OR_MORE_SUBCOMPONENTS',
    'INSTALLED',
    'os_is_unix',
    'os_is_windows',
    'exec_command',
    'copy_file',
    'update_target_file',
    ]


class PyMakException(StandardError):
    """This exception is used to flag an error all the way up to the
    top of the pymak system.

    When raising this exception it can be useful to add the underlying
    exception that caused the error if there is one.

    When this exception is created, it searches on the current stack
    to work out the top-most pymak file (.pymak) and adds this info into
    the attribute 'pymak_src' in the exception. If no pymak file is
    found (often because all the pymak files have been consumed and
    the targets are being built), then this attribute is set to None.
    """
    def __init__(self, arg=None, underlying_exception=None):
        StandardError.__init__(self, arg)
        self.underlying_exception = underlying_exception
        self.pymak_src = None
        for f, l, _, _ in traceback.extract_stack():
            if f.endswith('.pymak'):
                self.pymak_src ="File '%s', line %d" % (f, l)

    def report(self):
        logging.error(str(self))
        if self.underlying_exception:
            logging.error(_("Failure reason: %s"), str(self.underlying_exception))
        if self.pymak_src:
            logging.error(_("Failed when called from: %s"), self.pymak_src)
        


VAR_RE = re.compile(r"(.)?(\$\(\w+\))")

class NoMatchException(PyMakException):
    """This exception describes an error when variable expansion is occuring."""

    def __init__(self, var_name):
        PyMakException.__init__(self)
        self.variable_name = var_name
        self.contexts = []
    def set_context_string(self, str):
        """This exception stores a list of strings which describe the
        context in which the error happened. This can't be a single
        string because function that raises this exception
        (expand_variables) is recursive, and the error is intercepted
        at each level.
        """
        self.contexts.append(str)
    def __str__(self):
        return "Could not find match for '%s' in '%s'" % (self.variable_name, ", ".join(self.contexts))

class CommandFailedException(PyMakException):

    def __init__(self, cmd, out_and_err='', underlying_exception=None):
        PyMakException.__init__(self, underlying_exception=underlying_exception)
        self.cmd = cmd
        self.out_and_err = out_and_err

    def __str__(self):
        return "Command failed: %s\n%s" % (self.cmd, self.out_and_err)


class PipeToStream(threading.Thread):
    """A class derived from Thread which reads from a file handle, and
    writes the result to a supplied strean. The thread stops when the
    pipe returns no more data.
    """
    def __init__(self, fin, strm):
        self.fin = fin
        self.strm = strm
        threading.Thread.__init__(self)
    def run(self):
        while True:
            ln = self.fin.readline()
            if not ln:
                break
            self.strm.write(ln)

###########################
#
# Variable handling
#
###########################
    
def _lookup(mo, variant_list, context, target, full_paths):
    pre = mo.group(1)
    # Remove this for now because the \ on a path can
    # prevent a $(...) variable from expanding.
##    if pre == '\\':
##        # there was a preceding \, so leave it unmodified
##        return mo.group(2)
    tag = mo.group(2)[2:-1] # strip off "$(" and ")"
    if not pre:
        pre = ""

    repl = None
    if tag == "targetdir" and target:
        if full_paths:
            repl = target.path.parent.abspath()
        else:
            repl = target.path.parent
    elif tag == "targetfile" and target:
        repl = target.path.name
    elif tag == "targetfile_no_suffix" and target:
        repl = target.path.splitext()
    elif tag == "targetpath" and target:
        if full_paths:
            repl = target.path.abspath()
        else:
            repl = target.path
    elif tag == "targetpath_no_suffix" and target:
        path, suffix = target.target_file().split('.')
        if full_paths:
            repl = os.path.abspath(path)
        else:
            repl = path
    elif tag == "targetid" and target:
        repl = target.target_id
    elif tag == "allvariants":
        if not variant_list:
            repl = 'all'
        else:
            repl = variant_list.path_string()
    elif tag == "dependent_paths" and target:
        repl = ' '.join([t.path for t in target._depends_on.list_of_sub_targets(variant_list)])
    elif variant_list.contains(tag):
        repl = variant_list.lookup(tag)
    elif os.environ.has_key(tag):
        repl = os.environ[tag]
        repl = expand_variables(repl, variant_list, context, target, full_paths)
    else:
        prop = context.lookup_property(tag, variant_list)
        if prop:
            repl = " ".join(prop)
            # expand it again because the property might have $vars in it
            repl = expand_variables(repl, variant_list, context, target, full_paths)
        else:
            raise NoMatchException(tag)
    return pre + repl


def expand_variables(src, variant_list, context, target=None, full_paths=False):
    """Expand variables in the str. The valid variables are the variant names
    appropriate for the target being evaluated. An extra variable $(allvariants)
    is defined to be the combined list of all the variant values in variant_list.
    The variable name is also looked up in the current os environment if not
    found in the variant names"""
    try:
        return VAR_RE.sub(lambda mo: _lookup(mo, variant_list, context, target, full_paths), src)
    except NoMatchException, e:
        e.set_context_string(src)
        raise e


###########################
#
# Smart dictionaries for imports and installs.
#
###########################


# This enum defines the behaviour of the __call__ method on the ImportsDict object. See the
# doc for the ImportsDict.__call__ method for their meanings.
( BACKWARDS_COMPATIBILITY_MODE,
  SINGLETON_SUBCOMPONENT,
  ONE_OR_MORE_SUBCOMPONENTS,
  ZERO_OR_MORE_SUBCOMPONENTS,
  INSTALLED,
  ) = range(5)

class ImportsDict(object):
    """This dictionary container class contains the set of sub-components that
    are used to build a component.
    """
    def __init__(self):
        self.comps_table = {}
        self.namespace = ""

    def __call__(self, key, cardinality=BACKWARDS_COMPATIBILITY_MODE):
        """This method gets called when a pymak file uses the following pattern:
             sub_comps = pymak.read_import_file(....)
             foo = sub_comps('FOO')
           The cardinality argument specifies what the caller would like this function
           to assert and to return:

           BACKWARDS_COMPATIBILITY_MODE,   This will return either a string or an array of strings
                                           depending on the cardinality of the subcomponents.
           SINGLETON_SUBCOMPONENT,         Demands that there is only one instance of this
                                           subcomponent. Returns a string.
           ONE_OR_MORE_SUBCOMPONENTS,      This demands that there are at least one or
                                           more subcomponents. Returns a list.
           ZERO_OR_MORE_SUBCOMPONENTS,     Allows zero or more subcomponents. Always
                                           returns a list.
           INSTALLED,                      This is treated as a ONE_OR_MORE_SUBCOMPONENTS, but
                                           the word makes more sense for getting keys from
                                           the derived InstallsDict class.
           """
        if self.namespace and '.' not in key:
            key = self.namespace + '.' + key
        v = self.comps_table.get(key)
        if cardinality == BACKWARDS_COMPATIBILITY_MODE:
            if not v:
                raise PyMakException("Could not find sub-component '%s'. Check that qpbuild knows to build it." % key)
            if len(v) == 1:
                return v[0]
            return v
        elif cardinality == SINGLETON_SUBCOMPONENT:
            if not v:
                raise PyMakException("Could not find sub-component '%s'. Check that pbuild knows to build it." % key)
            if len(v) != 1:
                raise PyMakException("Found more than one sub-component '%s' when only expecting one." % key)
            return v[0]
        elif cardinality == ONE_OR_MORE_SUBCOMPONENTS or cardinality == INSTALLED:
            if not v:
                raise PyMakException("Could not find sub-component '%s'. Check that pbuild knows to build it." % key)
            return v
        elif cardinality == ZERO_OR_MORE_SUBCOMPONENTS:
            if v is None:
                v = []
            return v
        raise ValueError("Unknown cardinality value '%'" % cardinality)

    def __getitem__(self, key):
        return self(key) # call ourselves, defaulting to BACKWARDS_COMPATIBILITY_MODE

    def __setitem__(self, key, value):
        if self.namespace and '.' not in key:
            key = self.namespace + '.' + key
        self.comps_table[key] = value

    def keys(self):
        if self.namespace:
            keys = []
            strip = self.namespace + '.'
            for k in self.comps_table.keys():
                if k.startswith(strip):
                    k = k[len(strip):]
                keys.append(k)
            return keys
        else:
            return self.comps_table.keys()

    # A backwards-compatible items() method for existing code.
    def items(self):
        return [ (k, self(k)) for k in self.keys() ]

    # A backwards-compatible values() method for existing code.
    def values(self):
        return [ self(k) for k in self.keys() ]

    def assert_imports(self, **definitions):
        """This method is useful for extracting definitions from a
        ImportsDict into another dictionary while asserting the
        cardinality of the entries. The 'definitions' kargs parameter
        keys provide the element to lookup, and the value specifies
        the cardinality.

        It returns a simple dictionary which contains the value for
        each key after the cardinality of that value is checked.

        A common usage in a pymak4 file would be:

           sub_comps = pmc.read_import_file()
           globals().update(sub_comps.assert_imports(
              COMP_1=pymak.SINGLETON_SUBCOMPONENT,
              COMP_2=pymak.ONE_OR_MORE_SUBCOMPONENT,
              ...
              )

        Code would then be able to refer to COMP_1 which would be
        defined in the module's global namespace.

        In the case where the QPBuild namespace of the sub-component
        is not the same as the namespace of the current component,
        then the following syntax can be used:
              ...
              COMP_1=('NAMESPACE_X', pymak.SINGLETON_SUBCOMPONENT),
              ...
        i.e. the value is a 2-tuple, the first slot being the namespace
        and the second being the cardinality. The value bound into the
        returned dictionary is still the same key.
        """

        result = {}
        for key, val in definitions.items():
            cardinality = val
            lookup = key
            if type(val) == tuple:
                lookup = '.'.join([val[0], key])
                cardinality = val[1]
            imps = self(lookup, cardinality)
            result[key] = imps
        return result

class InstallsDict(ImportsDict):
    """This dictionary-derived class contains the set of prerequisite
    installed tools/packages that are needed to build this component.
    """
    pass


#################################
#
# Any target automatically makes its own directory, saving the user
# the pain of doing this. Here we maintain a cache of folders that
# have been made, or have been determined to exist already. This saves
# having to repeatidly call mkdir at the OS level.
#
#################################

_CREATED_DIRS = set([path.path(""), path.path(".")]) # current dir must exist
                         
def ensure_dir_exists(_path):
    """Ensure a directory has been created. The path name is cached so
    that if this function is called again, it will know that it has
    done it before and not bother checking on disk. The moral of this
    story is: Do not delete dirs in the course of a pymak.
    """
    _path = path.to_path(_path)
    if _path in _CREATED_DIRS:
        return
    makedir(_path)
    _CREATED_DIRS.add(_path)

def makedir(_path):
    """Construct directory and create any missing intermediates. The
    default mode is octal 755 (owner:rwx,group=rx,other:rx)
    """
    # If the path is not a directory, construct its parent...
    if not _path.isdir():
        head, tail = _path.splitpath()
        if head:
            # Recursively makes the directory
            makedir(head)
    # And so we can definitely construct the final directory
    # (since by now we know that all its parents must exist...)
    if not _path.isdir():
        _path.mkdir()

#########################
#
# Executing stuff
#
#########################

def os_is_unix():
    return sys.platform.startswith("linux") or sys.platform.startswith("sunos")

def os_is_windows():
    return sys.platform == "win32"

def exec_command(cmd, output_stream=None):
    """Execute a command in the system shell.

    If the process exits with a non-zero status, then this function
    raises the CommandFailedException.

    If output_stream is supplied, the stdout and stderr from the
    process is written to this stream as it is received. On success
    this function doesn't return anything.

    If the output_stream is not supplied, stdout and stderr are
    returned on a zero exit status of the subprocess.
    """

    shell = not os_is_windows()
    try:
        proc = subprocess.Popen(cmd, shell=shell, stdin=None, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
        if output_stream:
            PipeToStream(proc.stdout, output_stream).start()
            PipeToStream(proc.stderr, output_stream).start()
            result = proc.wait()
            if result != 0:
                raise CommandFailedException(cmd)
            return None
        else:
            out, err = proc.communicate()
            if proc.returncode != 0:
                raise CommandFailedException(cmd, out + err)
            return out, err
    except (IOError, OSError), e:
        logging.error("Failed to run: %s", cmd)
        raise CommandFailedException(cmd, '', e)
 


##    if os_is_windows():
##        # The extra set of quotes around the command are to defeat cmd.exe
##        # which mis-parses the command name when both the command name
##        # and some of the arguments are double-quoted strings.  An extra
##        # set of double quotes round the whole lot works.
##        (stdin, stdoutanderr) = os.popen4("\"" + cmd + "\"")
##    elif os_is_unix():
##        childprocess = popen2.Popen4(cmd)
##        stdin = childprocess.tochild
##        stdoutanderr = childprocess.fromchild
##    else:
##        assert False, "Unknown OS"

##    outanderr = cStringIO.StringIO()
##    while True:
##        output = stdoutanderr.readline()
##        if not output:
##            break
##        if output_stream:
##            output_stream.write(output)
##        outanderr.write(output)

##    if os_is_windows():
##        stdin.close()
##        status = stdoutanderr.close()
##    elif os_is_unix():
##        status = childprocess.wait()
##        stdin.close()
##        stdoutanderr.close()

##    buff = outanderr.getvalue()
##    outanderr.close()

##    if not status:
##        return buff
##    else:
##        raise CommandFailedException(cmd, buff)


##################################
#
# Copying stuff
#
#################################

def copy_file(src, dst, make_writable=False):
    """This file copying utility copies a file without updating the
    time stamp. This is useful for distribution rules which want to
    preserve the time-stamp of the orignal, but not the permission
    bits.
    """
    logging.info(_("Copying '%s' to '%s'"), src, dst)
    if os_is_unix():
        # Make sure we preserve executable permission
        # bits, copy2 also preserves the time stamp
        shutil.copy2(src, dst)
    else:
        # copyfile changes permissions on files, for
        # example making files writable that were not
        # before. Left this way for backwards compatiblity
        shutil.copyfile(src, dst)
        st = os.stat(src)
        mode = stat.S_IMODE(st.st_mode)
        if hasattr(os, 'utime'):
            os.utime(dst, (st.st_atime, st.st_mtime))

    # Make sure we can over-write the file
    if make_writable:
        os.chmod(dst, os.stat(dst).st_mode | stat.S_IWRITE)


def update_target_file(src_file, dest_file):
    """Copy a file only if the source is newer that the destination or if
    the destination file does not exist.
    """
    src = path.to_path(src_file)
    dst = path.to_path(dest_file)
    if src.isdir():
        for f in src.listdir():
            update_target_file(f, dst / f.name)
    else:
        head, tail = dst.splitpath()
        if not head.isdir():
            needs_update = True               # we cannot find even the destination directory, create it
            makedir(head)
        else:
            try:
                needs_update = not filecmp.cmp(src, dst)     # filecmp.cmp returns True if files are equal, False otherwise
            except OSError:
                needs_update = True           # destination does not exist
        if needs_update:
            # Copy the file preserving the original's time stamp, but not its permissions
            copy_file(src, dst)
